"use client"; /** * ActivateClient * * Shown after sign-up/sign-in on /activate/[orderId]. * * - Fetches the order and shows the real eSIM QR code once provisioning is done. * - Offers a one-tap install, then the SM-DP+ address and matching ID, then * written steps. A QR code is useless on the one phone the customer is * reading this on, so every layer below it has to stand on its own. * - Says plainly when it cannot read the order at all. The page used to print * a fixed reassurance that a QR code was on its way, and then never * ask anyone anything, which reads as progress and is not. * - Never spins forever. A spinner is a promise that something is still * happening; when the answer is "this order does not exist", "it is not * yours", or "provisioning has not finished in the time it normally takes", * the page stops and says which, with something the customer can do next. * - Optionally collects phone number / WhatsApp for eSIM delivery notifications. * Phone is NEVER required — user can skip. */ import { useCallback, useEffect, useRef, useState, FormEvent } from "react"; import { ANDROID_INSTALL_STEPS, IPHONE_INSTALL_STEPS, directInstallUrl, manualActivationCode, } from "@/lib/esim-install"; import type { EsimOrder } from "@/vendor/carrier/types"; interface Props { orderId: string; } type LoadState = | { phase: "loading" } | { phase: "ready"; order: EsimOrder } /** * `terminal` means retrying cannot change the answer. A 404 or a 403 is a * fact about this order, not a hiccup, so the page offers support instead of * a "Try again" button that will fail identically. */ | { phase: "error"; message: string; terminal: boolean } /** Still provisioning after the polls ran out. Not an error, not progress. */ | { phase: "stalled"; order: EsimOrder }; /** Provisioning is usually seconds. Poll a bounded number of times, then stop. */ const POLL_INTERVAL_MS = 4000; const MAX_POLLS = 15; /** * What happened to the delivery email, as the page is allowed to describe it. * * "unconfigured" is on this list because a storefront with no Resend key is a * working storefront, and the page must say "we cannot email this" rather than * letting the customer leave believing a copy is on its way. */ type EmailState = | { phase: "idle" } | { phase: "sending" } | { phase: "sent" } | { phase: "unavailable"; reason: string }; export function ActivateClient({ orderId }: Props) { const [phone, setPhone] = useState(""); const [submitted, setSubmitted] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [state, setState] = useState({ phase: "loading" }); const [copied, setCopied] = useState(false); const [email, setEmail] = useState({ phase: "idle" }); const pollsRef = useRef(0); const emailAskedRef = useRef(false); // Bumped by "Check again". The polling effect depends on it, so a new value // tears the old loop down and starts a fresh one. Resetting the counter and // calling loadOrder once would fetch a single time and then sit on the // spinner forever, because loadOrder is stable and the effect never re-runs. const [pollCycle, setPollCycle] = useState(0); const loadOrder = useCallback(async (): Promise => { try { const res = await fetch(`/api/orders/${encodeURIComponent(orderId)}`, { cache: "no-store", }); const payload = (await res.json().catch(() => ({}))) as { order?: EsimOrder; error?: string; code?: string; }; if (!res.ok || !payload.order) { // 404 and 403 are settled answers: the order is unknown, or it is not // this account's. Retrying returns the same status, and a spinner over // the top of it is a lie about what the page is waiting for. const terminal = res.status === 404 || res.status === 403 || payload.code === "not_found"; setState({ phase: "error", terminal, message: payload.error ?? (terminal ? "We can't find this order on your account." : "We could not load your eSIM details right now."), }); return null; } setState({ phase: "ready", order: payload.order }); return payload.order; } catch { setState({ phase: "error", terminal: false, message: "We could not reach your eSIM details. Check your connection and try again.", }); return null; } }, [orderId]); useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; async function tick() { const order = await loadOrder(); if (cancelled) return; const stillWorking = order?.status === "pending" || order?.status === "provisioning"; if (!stillWorking) return; if (pollsRef.current < MAX_POLLS) { pollsRef.current += 1; timer = setTimeout(tick, POLL_INTERVAL_MS); return; } // Out of polls and still not ready. Stop the spinner and say so — it has // now taken materially longer than provisioning normally does, and // "we're preparing your eSIM" under an animation is no longer true. setState({ phase: "stalled", order }); } void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [loadOrder, pollCycle]); /** * Ask the server to email this eSIM, once, the moment it is ready. * * The page has always promised "we'll email you the QR code" and, until * there was a sender, nothing was ever sent. The request is fired from here * rather than from fulfilment because this is the first place that knows the * order reached `ready`; the route itself carries the ownership check and * the send-once marker, so a reload does not produce a second copy. */ useEffect(() => { if (state.phase !== "ready" || state.order.status !== "ready") return; if (emailAskedRef.current) return; emailAskedRef.current = true; let cancelled = false; setEmail({ phase: "sending" }); void (async () => { try { const res = await fetch(`/api/orders/${encodeURIComponent(orderId)}/email`, { method: "POST", }); const payload = (await res.json().catch(() => ({}))) as { sent?: boolean; reason?: string; }; if (cancelled) return; if (res.ok && payload.sent) { setEmail({ phase: "sent" }); return; } setEmail({ phase: "unavailable", reason: payload.reason ?? "unavailable" }); } catch { if (!cancelled) setEmail({ phase: "unavailable", reason: "network" }); } })(); return () => { cancelled = true; }; }, [state, orderId]); async function handlePhoneSubmit(e: FormEvent) { e.preventDefault(); setSaving(true); setSaveError(null); try { // POST phone to user profile (best-effort — non-blocking) const res = await fetch("/api/profile/phone", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone }), }); if (!res.ok) { const err = (await res.json()) as { error?: string }; throw new Error(err.error ?? "Failed to save phone number"); } setSubmitted(true); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to save. Please try again."; setSaveError(msg); } finally { setSaving(false); } } async function copyActivationCode(code: string) { try { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { setCopied(false); } } const order = state.phase === "ready" || state.phase === "stalled" ? state.order : null; const ready = order?.status === "ready"; const manualCode = order ? manualActivationCode(order) : null; return (
{/* Header */}
{ready ? "✓" : "•"}

{ready ? "Your eSIM is ready" : "Setting up your eSIM"}

Order: {orderId}

{state.phase === "loading" && ( <>

Loading your eSIM…

)} {state.phase === "error" && ( <>

{state.terminal ? "We can't show this eSIM" : "We couldn't load your eSIM"}

{state.message}

Your payment is safe and your order number is above. Quote it if you contact us.

{/* No "Try again" on a terminal answer: the same request would return the same status, and a button that cannot work is worse than no button. */} {!state.terminal && ( )} Contact support
)} {state.phase === "stalled" && ( <>

This is taking longer than it should

Your payment went through and your order is queued, but the eSIM isn't ready yet. We'll email your QR code as soon as it is — you don't have to wait here.

Contact support
)} {state.phase === "ready" && order && !ready && order.status !== "failed" && ( <>

We're preparing your eSIM. This usually takes less than a minute.

You can leave this page — we'll email you the QR code when it's ready.

)} {order?.status === "failed" && ( <>

Your eSIM couldn't be set up

{order.failureMessage ?? "Something went wrong while activating your plan. Our team can sort this out for you."}

Contact support )} {ready && ( <> {order?.qrCode ? ( /* The QR image is served by the fulfilment API; next/image would need every possible operator origin allow-listed in next.config, so this stays a plain img on purpose. */ // eslint-disable-next-line @next/next/no-img-element eSIM activation QR code ) : (

Your eSIM is active. Enter the details below on your phone to install it.

)}

Scan this with your phone's camera, or add the eSIM manually with the details below.

{order?.planName &&

{order.planName}

} )}
{/* What happened to the email, in one line. The page promises a copy by email in two other places; if none can be sent, it has to say so here rather than let the customer navigate away expecting one. */} {ready && email.phase !== "idle" && (

{email.phase === "sending" && "Emailing you a copy…"} {email.phase === "sent" && "We've emailed a copy to the address that paid."} {email.phase === "unavailable" && (email.reason === "unconfigured" || email.reason === "no-from-address" ? "Email delivery isn't set up on this store, so keep this page — everything you need to install is on it." : "We couldn't email you a copy. Everything you need to install is on this page.")}

)} {/* One tap, no typing — iOS 17.4+ only, and harmless everywhere else: a phone that cannot handle the link opens a page that explains the manual route, which is the next card down. */} {ready && manualCode && ( Install this eSIM on this iPhone )} {/* Manual install — always available once we know the credentials, because a QR is useless on the one phone the customer is reading this on. */} {ready && (order?.smdpAddress || manualCode) && (

Install manually

{order?.smdpAddress && (
SM-DP+ address
{order.smdpAddress}
)} {order?.matchingId && (
Activation code
{order.matchingId}
)} {order?.iccid && (
ICCID
{order.iccid}
)}
{manualCode && ( )} {/* The steps in words. A customer whose camera cannot reach a second screen, and whose phone is too old for the one-tap link, has the credentials above and no idea where to type them. */}

On iPhone

    {IPHONE_INSTALL_STEPS.map((step) => (
  1. {step}
  2. ))}

On Android

    {ANDROID_INSTALL_STEPS.map((step) => (
  1. {step}
  2. ))}
)} {/* Optional phone / WhatsApp collection */}
?

Get eSIM updates via WhatsApp{" "} (optional)

We'll send your QR code and activation status. You can skip this.

{submitted ? (

✓ Phone number saved. We'll send updates to {phone}.

) : (
setPhone(e.target.value)} placeholder="+1 555 000 0000" className="flex-1 rounded-lg border border-sand-200 dark:border-sand-800 bg-sand-50 dark:bg-sand-950 px-3 py-2 text-sm text-sand-900 dark:text-sand-50 placeholder:text-sand-400 focus:border-[var(--brand-accent)] focus:outline-none" />
{saveError &&

{saveError}

}
)}
); }